Skip to content

feat(mcp): worker-thread liveness watchdog to self-kill a wedged main thread (#850) - #856

Merged
colbymchenry merged 1 commit into
mainfrom
fix/850-liveness-watchdog
Jun 13, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
fix/850-liveness-watchdog

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Follow-up to #855 (the #850 handler fix). That PR removed the known trigger of the 100% CPU wedge; this adds the belt-and-suspenders defense I flagged: a watchdog that can recover from any future main-thread wedge.

Why

Any synchronous, non-yielding loop on the main thread — a future V8 stack-format pathology, a runaway regex, an accidental while (true) — blocks the event loop. From JS you can't interrupt it: timers, signal handlers, and the PPID watchdog all run on that blocked loop, so the process pins a core forever with no self-recovery. The only observer still running is another thread.

How

src/mcp/liveness-watchdog.ts spawns a tiny worker thread (in the two long-lived in-process modes — the detached daemon, which notably has no PPID watchdog or stdin lifeline, and direct mode). The main thread bumps a SharedArrayBuffer heartbeat each event-loop turn; the worker watches it. If it stops advancing across enough consecutive checks (~CODEGRAPH_WATCHDOG_TIMEOUT_MS, default 60s), the worker SIGKILLs the process — the one signal a wedged loop can't swallow — so a fresh daemon starts on the next connection.

Safety (false positives were the main design risk)

  • Counts consecutive stale checks, not wall-clock delta → immune to clock jumps / laptop sleep. On wake a healthy main thread bumps the heartbeat within one interval, resetting the count; only a thread that never resumes keeps it climbing. (stepHeartbeat, unit-tested.)
  • Tuned never to fire on real work: heavy parsing runs in the parse worker (off-thread) and indexing shells out to a child process, so the daemon's main thread only does fast, bounded work. 60s is ~300× shorter than the 5h codegraph cause 100% CPU #850 wedge yet far longer than any legitimate block.
  • unref'd (never delays shutdown — verified against the idle-timeout suite), worker errors swallowed (can't escalate to the now-exiting global handler), inline eval worker (no dist-path fragility).
  • Opt out with CODEGRAPH_NO_WATCHDOG=1; tune with CODEGRAPH_WATCHDOG_TIMEOUT_MS.

Tests

__tests__/liveness-watchdog.test.ts (10): pure reducer + config parsing, plus three spawn-based end-to-end cases against the built module — wedge → SIGKILL (646 ms), healthy → survives & exits on its own, opt-out honored. Full suite green (75 files, 1508 passed / 2 skipped), incl. the daemon idle-timeout/lifecycle suites.

🤖 Generated with Claude Code

… thread (#850)

Belt-and-suspenders beyond the #850 handler fix. Any non-yielding sync loop on
the main thread — a future V8 stack-format pathology, a runaway regex, an
accidental while(true) — wedges the event loop, and nothing running ON that loop
(timers, signal handlers, the PPID watchdog) can fire to recover it. Only another
thread can.

Install a tiny worker thread in the two long-lived in-process modes (the detached
daemon, which has no PPID watchdog or stdin lifeline, and direct mode) that
watches a shared-memory heartbeat the main thread bumps each event-loop turn. If
the heartbeat stops advancing across enough consecutive worker checks
(~CODEGRAPH_WATCHDOG_TIMEOUT_MS, default 60s), the worker SIGKILLs the process —
the one signal a wedged loop can't swallow — so a fresh daemon starts on the next
connection.

Counting consecutive stale checks (not a wall-clock delta) makes it immune to
clock jumps / laptop sleep: a healthy main thread resumes and bumps the heartbeat
within one interval of waking, resetting the count; only a thread that never
resumes keeps it climbing. Tuned never to fire on real work (heavy parsing runs
off-thread, indexing shells out to a child), unref'd so it never delays shutdown,
worker errors swallowed so they can't escalate to the now-exiting global handler,
and opt-out via CODEGRAPH_NO_WATCHDOG=1.

Pure reducer (stepHeartbeat) + config parsing unit-tested; the real worker is
spawn-tested end-to-end (wedge -> SIGKILL, healthy -> survives, opt-out honored).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@colbymchenry
colbymchenry merged commit 576149e into main Jun 13, 2026
1 check passed
@colbymchenry
colbymchenry deleted the fix/850-liveness-watchdog branch June 13, 2026 15:04
colbymchenry added a commit that referenced this pull request Jun 13, 2026
… thread (#858)

The worker-thread watchdog from #856 didn't work in the real daemon — caught by
live-testing against a real serve --mcp. V8 isolates coordinate on global
safepoints, so a main thread wedged in a tight non-allocating loop (#850's
SourcePositionTableIterator::Advance) strands the watchdog worker before it can
SIGKILL.

A separate child process shares no isolate/heap with the parent, so the wedge
can't touch it; it kills via the kernel. Parent heartbeats to the child's stdin;
silence past the timeout -> SIGKILL; parent exit closes the pipe -> child exits.
Validated live (real daemon SIGKILLed in ~timeout); regression test covers the
non-allocating-wedge-under-heap-pressure case. API/install points/CHANGELOG
unchanged; the broken worker version was never released.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant